<?php

declare(strict_types=1);

namespace WebTus;

use Exception;

/**
 * 文件db存储
 * Class TusStoreDb
 */
class TusStoreDb extends TusStoreAbstract implements TusStoreInterface
{
    public const TMP_DIR_NAME = '.tmp';
    public const INFO_SUFFIX = '.info';

    public $gcProbability = 100; //100000次设置有x次机率触发垃圾回收

    //文件锁
    private $lockFile;
    private $lockHandle;

    public function __construct(string $storePath, int $upload_expires = 86400)
    {
        $this->uploadExpires = $upload_expires;

        $this->dir = rtrim($storePath, '/') . '/';
        $this->tempDir = $this->dir . self::TMP_DIR_NAME . '/';
        if (!is_dir($this->dir)) {
            mkdir($this->dir, 0755, true);
        }
        if (!is_dir($this->tempDir)) {
            mkdir($this->tempDir, 0755);
        }
    }

    public function lock(string $id): bool
    {
        $this->lockFile = $this->tempDir . $id . '.lock';
        $this->lockHandle = @fopen($this->lockFile, 'w');
        if (!$this->lockHandle) {
            return false;
        }
        //LOCK_EX 获取独占锁
        //LOCK_NB 无法建立锁定时，不阻塞
        return @flock($this->lockHandle, LOCK_EX | LOCK_NB);
    }

    public function unlock(string $id)
    {
        if (!$this->lockHandle) {
            return;
        }
        @flock($this->lockHandle, LOCK_UN);
        @fclose($this->lockHandle);
        @unlink($this->lockFile); //这里会报错
    }

    public function getFileHash(string $hash): ?TusFileHash
    {
        $json = db()->table('hash')->where(['hash' => $hash])->val('json');
        return $json ? new TusFileHash(\json_decode($json, true)) : null;
    }

    public function getInfo(string $id): ?TusFileInfo
    {
        $json = db()->table('info')->where(['id' => $id])->val('json');
        if ($json) {
            return new TusFileInfo($id, \json_decode($json, true));
        } else {
            $file = $this->tempDir . $id . self::INFO_SUFFIX;
            if (file_exists($file)) {
                return new TusFileInfo($id, \json_decode(file_get_contents($file), true));
            }
        }
        return null;
    }

    /**
     * @param string $file 文件路径
     * @return TusFileInfo|null
     */
    public function getFile2Info(string $file): ?TusFileInfo
    {
        $file = trim($file, '/');
        $real_file = $this->dir . $file;
        if (!is_file($real_file)) {
            return null;
        }
        $path = dirname($file);
        //模拟info
        $info = new TusFileInfo();
        $info->id = md5($file);
        $info->metadata = ['name' => basename($file)];
        $info->path = $path;
        $info->offset = $info->size = filesize($real_file);
        $info->mtime = filemtime($real_file);

        return $info;
    }

    /**
     * @param string $hash
     * @return TusFileInfo|null
     */
    public function getHash2Info(string $hash): ?TusFileInfo
    {
        $fileHash = $this->getFileHash($hash);
        if (!$fileHash) {
            return null;
        }
        //模拟info
        $info = new TusFileInfo($fileHash->id);
        $info->metadata = ['name' => $fileHash->name];
        $info->path = $fileHash->path;
        $info->hash = $hash;
        $info->offset = $info->size = $fileHash->size;
        $info->mtime = $fileHash->mtime ?: time();
        return $info;
    }

    public function newInfo(TusFileInfo &$info): bool
    {
        if ($this->fileCoverWays == self::FILE_SKIP) {
            $fileInfo = $this->getInfo($info->id);
            if ($fileInfo) { //文件已存在
                $info = $fileInfo;
                return true;
            }
        }

        $file = $this->tempDir . $info->id . self::INFO_SUFFIX;
        if (file_exists($file)) { //文件未上传完成
            $info = new TusFileInfo($info->id, \json_decode(file_get_contents($file), true));
            return true;
        }
        //触发清理过期文件
        $this->clear();
        //是否存在hash
        if (isset($info->metadata['hash'])) {
            $fileHash = $this->getHash2Info($info->metadata['hash']);
            if ($fileHash) { //文件已存在
                $info = $fileHash;
                return true;
            }
            unset($info->metadata['hash']);
        }

        //自定义路径处理
        if ($info->path === '') {
            $info->path = $this->fmtPath($info->metadata['path'] ?? '');
        }
        if (isset($info->metadata['path'])) {
            unset($info->metadata['path']);
        }

        $info->isTemp = true;
        $result = file_put_contents($file, $info, LOCK_EX);

        if ($result && $this->fileHashAlgorithm && self::$cli) { //cli下同步计算hash
            $this->hash_init[$info->id] = hash_init($this->fileHashAlgorithm);
        }

        return $result !== false;
    }

    public function updateTmpInfo(TusFileInfo $info)
    {
        $file = $this->tempDir . $info->id . self::INFO_SUFFIX;
        if (!file_exists($file)) {
            return false;
        }
        return file_put_contents($file, $info, LOCK_EX);
    }

    public function write($in, TusFileInfo $info, int $maxSize = -1): int
    {
        $file = fopen($this->tempDir . $info->id, 'ab');
        if (is_string($in)) {
            $bytes = fwrite($file, $in, $maxSize);
            fclose($file);
            return $bytes;
        }

        $bytes = 0;
        /*
        if (self::$cli) {
            while (!feof($in)) {
                $data = stream_get_contents($in, $this->chunkSize, $bytes);
                isset($this->hash_init[$info->id]) && hash_update($this->hash_init[$info->id], $data);
                $size = fwrite($file, $data, $this->chunkSize);
                $bytes += $size;
            }
        } else {
            while (!feof($in)) { //小于分块大小的文件 在这里会阻塞卡死
                $size = stream_copy_to_stream($in, $file, $this->chunkSize, $bytes); //xxK , $info->offset
                $bytes += $size;
            }
        }*/
        while (!feof($in)) { //按分块读取写入是为了支持断点续传
            $data = fread($in, $this->chunkSize);
            isset($this->hash_init[$info->id]) && hash_update($this->hash_init[$info->id], $data);
            $size = fwrite($file, $data, $this->chunkSize);
            $bytes += $size;
        }
        //$bytes = stream_copy_to_stream($in, $file, $maxSize); //, $info->offset //此方式不支持断点续传

        fclose($file);
        return $bytes;
    }

    public function merge(TusFileInfo $info): bool
    {
        $file = fopen($this->tempDir . $info->id, 'wb');
        $writeSize = 0;
        foreach ($info->partial as $partId) {
            $partInfo = $this->getInfo($partId);
            $in = fopen($this->file2path($partInfo->path, $partInfo->metadata['name']), 'rb');
            if (!$in) {
                fclose($file);
                return false;
            }
            $writeSize += stream_copy_to_stream($in, $file);
            fclose($in);
        }
        fclose($file);
        return $info->size == $writeSize; //判断大小是否一致
    }

    public function output(TusFileInfo $info = null, int $offset = 0, int $size = -1): TusReadFile
    {
        $file = $info->isTemp ? $this->tempDir . $info->id : $this->file2path($info->path, $info->metadata['name']);
        return new TusReadFile($file, $offset, $size);
        /*
        $out = fopen('php://output', 'wb');
        $fp = fopen($file, 'rb');
        stream_copy_to_stream($fp, $out, $size, $offset);
        fclose($fp);
        fclose($out);
        return null;*/
    }

    public function delete(TusFileInfo $info = null)
    {
        if ($info->isTemp) {
            unlink($this->tempDir . $info->id . self::INFO_SUFFIX);
            unlink($this->tempDir . $info->id);
        } else {
            $this->delInfo($info->id);
            if ($info->hash) {
                $fileHash = $this->getFileHash($info->hash);
                $fileHash->times(-1);

                if ($fileHash->times == 0) {
                    unlink($this->file2path($fileHash->path, $fileHash->name)); //原始文件
                    $this->delHash($info->hash);
                } else {
                    $this->saveHash($info->hash, $fileHash);
                }
            } else {
                unlink($this->file2path($info->path, $info->metadata['name']));
            }
        }
    }

    public function complete(TusFileInfo $info)
    {
        if ($this->fileHashAlgorithm) { //hash去重
            //驻入内存时可实现同步计算散列值 $hash_init[$info->id]
            if (isset($this->hash_init[$info->id])) {
                $info->hash = hash_final($this->hash_init[$info->id]);
                unset($this->hash_init[$info->id]);
            } else {
                $info->hash = hash_file($this->fileHashAlgorithm, $this->tempDir . $info->id);
            }
        }

        if ($info->checksum) { //处理文件校验
            [$algorithm, $checksum] = explode(' ', $info->checksum); //校验算法 base64(检验值)
            $file_checksum = base64_encode($algorithm == $this->fileHashAlgorithm ? hex2bin($info->hash) : hash_file($algorithm, $this->tempDir . $info->id, true));

            if ($checksum !== $file_checksum) {
                $this->delete($info); //删除校验无效的文件
                throw new Exception('Checksum Mismatch', TusService::STATUS_CODE_460);
            }
        }

        //去重处理
        $fileOk = true;
        $infoId = $info->id;
        if ($info->hash) {
            $fileHash = $this->getFileHash($info->hash);
            if ($fileHash) { //存在hash的文件
                if ($fileHash->size != $info->size) { //hash相同文件大小不同 存在hash碰撞
                    $this->delete($info); //删除临时上传文件及info //todo 这里可考虑保留上传临时文件 通过定时器方式清除临时文件 方便终端重复上传判断处理
                    throw new Exception('Hash Conflict', TusService::STATUS_CODE_409);
                }
                unlink($this->tempDir . $info->id); //删除临时上传文件

                //文件标识相同 清除临时文件信息 并返回
                //if ($info->id == $fileHash->id) {
                //hash去重
                $info->path = $fileHash->path;
                $info->metadata['name'] = $fileHash->name;
                unlink($this->tempDir . $info->id . self::INFO_SUFFIX);
                return;
                //}
                //文件标识不相同 次数加1
                $fileHash->times(1); //$info->id != $fileHash->id &&
                $fileOk = false;
                //记录原始文件路径
                $info->ori = $this->file2path($fileHash->path, $fileHash->name, false);
                $info->metadata['ori_name'] = $fileHash->name;
                //$info->path = $fileHash->path;
                //$info->metadata['name'] = $fileHash->name;
            } else {
                $fileHash = new TusFileHash(['id' => $info->id, 'name' => $info->metadata['name'], 'path' => $info->path, 'size' => $info->size, 'mtime' => $info->mtime]);
            }
            $this->saveHash($info->hash, $fileHash);
        }
        //生成目录及保存文件
        if ($fileOk) {
            if ($info->path !== '' && !is_dir($this->dir . $info->path)) {
                @mkdir($this->dir . $info->path, 0755, true);
            }

            $file_path = $this->file2path($info->path, $info->metadata['name']);
            //文件名重复处理
            if (file_exists($file_path)) {
                if ($this->fileCoverWays === self::FILE_SKIP) { //重复名跳过
                    $this->delete($info); //删除临时上传文件
                    throw new Exception('File Conflict', TusService::STATUS_CODE_409);
                } elseif ($this->fileCoverWays === self::FILE_DUPLICATE) { //允许重复名
                    $name_parts = pathinfo($info->metadata['name']);
                    $extension = isset($name_parts['extension']) ? '.' . $name_parts['extension'] : '';
                    for ($i = 0; $i < 10000; $i++) {
                        $info->metadata['name'] = $name_parts['filename'] . '_' . date("Ymd_His") . ($i > 0 ? $i : '') . $extension;
                        $file_path = $this->file2path($info->path, $info->metadata['name']);
                        if (!file_exists($file_path)) {
                            $infoId = md5(($info->path !== '' ? '/' : '') . $info->path . '/' . $info->metadata['name']);
                            break;
                        }
                    }
                }
            }

            //处理上传完成
            if (!rename($this->tempDir . $info->id, $file_path)) {
                throw new Exception("File move failed");
            }
            //设置修改文件的时间
            if (isset($info->metadata['lastModified']) && is_numeric($info->metadata['lastModified']) && strlen($info->metadata['lastModified']) == 13) {
                @touch($file_path, (int)substr($info->metadata['lastModified'], 0, 10));
            }
        }

        //生成info存放目录 去除临时标识 迁移到info存放目录
        $info->isTemp = false;
        if ($infoId == $info->id) {
            $this->saveInfo($info);
            //清除临时文件信息
            unlink($this->tempDir . $info->id . self::INFO_SUFFIX);
        } else { //文件重名了
            //清除临时文件信息
            unlink($this->tempDir . $info->id . self::INFO_SUFFIX);
            //生成新命名的文件信息
            $info->id = $infoId;
            $this->saveInfo($info);
            //更新hash文件信息中的id
            if ($info->hash) {
                $fileHash->id = $infoId;
                $this->saveHash($info->hash, $fileHash);
            }
        }
    }

    /** 删除所有文件
     * @param bool $force
     */
    public function clear(bool $force = false)
    {
        if ($force || mt_rand(0, 100000) < $this->gcProbability) {
            TusService::log('INFO cache gc:' . ($force ? 'force' : 'probability'));
            $this->gcRecursive($this->tempDir, $this->uploadExpires);
        }
    }

    //清除 $maxLifeTime 时间之前的文件
    protected function gcRecursive(string $path, int $maxLifeTime, bool $isRecursive = true)
    {
        if (($directory = opendir($path)) === false) {
            TusService::log("WARN Couldn't list files under directory '" . $path);
            return;
        }
        $ts = time() - $maxLifeTime;
        while (($file = readdir($directory)) !== false) {
            if ($file[0] === '.') {
                continue;
            }
            $fullPath = $path . DIRECTORY_SEPARATOR . $file;
            if (is_dir($fullPath)) {
                if (!$isRecursive) {
                    continue;
                }
                $this->gcRecursive($fullPath, $maxLifeTime, $isRecursive);
                if (count(scandir($fullPath)) == 2) {
                    @rmdir($fullPath);
                }
            } else {
                if (!($mtime = @filemtime($fullPath)) || $mtime > $ts) {
                    continue;
                }
                @unlink($fullPath);
            }
        }
        closedir($directory);
    }

    private function saveInfo(TusFileInfo $info): bool
    {
        try {
            $has = db()->table('info')->fields('id')->where(['id' => $info->id])->one();
            if ($has) {
                db()->update(['json' => (string)$info], 'info', ['id' => $info->id]);
            } else {
                db()->add(['id' => $info->id, 'json' => (string)$info], 'info');
            }
        } catch (\Exception $e) {
            TusService::log('saveInfo fail:' . $e->getMessage());
            return false;
        }
        return true;
    }

    private function delInfo(string $id)
    {
        db()->del('info', ['id' => $id]);
    }

    private function saveHash(string $hash, TusFileHash $fileHash): bool
    {
        db()->beginTrans();
        try {
            $has = db()->table('hash')->fields('hash')->where(['hash' => $hash])->lock()->one();
            if ($has) {
                db()->update(['json' => (string)$fileHash], 'hash', ['hash' => $hash]);
            } else {
                db()->add(['hash' => $hash, 'json' => (string)$fileHash], 'hash');
            }
            db()->commit();
        } catch (\Exception $e) {
            db()->rollBack();
            TusService::log('saveHash fail:' . $e->getMessage());
            return false;
        }
        return true;
    }

    private function delHash(string $hash)
    {
        db()->del('hash', ['hash' => $hash]);
    }
}
